574 lines
23 KiB
TypeScript
574 lines
23 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import { Campaign, Proposition, Participant, Vote, PropositionWithVote } from '@/types';
|
|
import { campaignService, participantService, propositionService, voteService, settingsService } from '@/lib/services';
|
|
import { MarkdownContent } from '@/components/MarkdownContent';
|
|
import { PROJECT_CONFIG } from '@/lib/project.config';
|
|
import Footer from '@/components/Footer';
|
|
|
|
// Force dynamic rendering to avoid SSR issues with Supabase
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
export default function PublicVotePage() {
|
|
const params = useParams();
|
|
const router = useRouter();
|
|
const campaignId = params.id as string;
|
|
const participantId = params.participantId as string;
|
|
|
|
const [campaign, setCampaign] = useState<Campaign | null>(null);
|
|
const [participant, setParticipant] = useState<Participant | null>(null);
|
|
const [propositions, setPropositions] = useState<PropositionWithVote[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [success, setSuccess] = useState(false);
|
|
|
|
// Votes temporaires stockés localement
|
|
const [localVotes, setLocalVotes] = useState<Record<string, number>>({});
|
|
const [totalVoted, setTotalVoted] = useState(0);
|
|
const [isRandomOrder, setIsRandomOrder] = useState(false);
|
|
const [isCompactView, setIsCompactView] = useState(false);
|
|
const [currentVisibleProposition, setCurrentVisibleProposition] = useState(1);
|
|
const [isOverBudget, setIsOverBudget] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (campaignId && participantId) {
|
|
loadData();
|
|
}
|
|
}, [campaignId, participantId]);
|
|
|
|
// Écouter les changements de connectivité réseau
|
|
useEffect(() => {
|
|
const handleOnline = () => {
|
|
console.log('Connexion réseau rétablie');
|
|
setError('');
|
|
};
|
|
|
|
const handleOffline = () => {
|
|
console.log('Connexion réseau perdue');
|
|
setError('Connexion réseau perdue. Veuillez vérifier votre connexion internet.');
|
|
};
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('online', handleOnline);
|
|
window.addEventListener('offline', handleOffline);
|
|
}
|
|
|
|
return () => {
|
|
if (typeof window !== 'undefined') {
|
|
window.removeEventListener('online', handleOnline);
|
|
window.removeEventListener('offline', handleOffline);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Calculer le total voté à partir des votes locaux
|
|
useEffect(() => {
|
|
const total = Object.values(localVotes).reduce((sum, amount) => sum + amount, 0);
|
|
setTotalVoted(total);
|
|
|
|
// Vérifier si on dépasse le budget
|
|
if (campaign && total > campaign.budget_per_user) {
|
|
setIsOverBudget(true);
|
|
// Arrêter la vibration après 1 seconde
|
|
setTimeout(() => setIsOverBudget(false), 1000);
|
|
} else {
|
|
setIsOverBudget(false);
|
|
}
|
|
}, [localVotes, campaign]);
|
|
|
|
// Observer les propositions visibles
|
|
useEffect(() => {
|
|
if (propositions.length === 0) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
let highestVisibleIndex = 1;
|
|
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
const propositionIndex = parseInt(entry.target.getAttribute('data-proposition-index') || '1');
|
|
if (propositionIndex > highestVisibleIndex) {
|
|
highestVisibleIndex = propositionIndex;
|
|
}
|
|
}
|
|
});
|
|
|
|
if (highestVisibleIndex > 1) {
|
|
setCurrentVisibleProposition(highestVisibleIndex);
|
|
}
|
|
},
|
|
{
|
|
threshold: 0.3, // La proposition doit être visible à 30% pour être considérée comme active
|
|
rootMargin: '-10% 0px -10% 0px' // Zone de détection réduite
|
|
}
|
|
);
|
|
|
|
// Attendre que le DOM soit mis à jour
|
|
setTimeout(() => {
|
|
const propositionElements = document.querySelectorAll('[data-proposition-index]');
|
|
propositionElements.forEach((element) => observer.observe(element));
|
|
}, 100);
|
|
|
|
return () => observer.disconnect();
|
|
}, [propositions, isCompactView]);
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError('');
|
|
|
|
// Vérifier la connectivité réseau
|
|
if (typeof window !== 'undefined' && !navigator.onLine) {
|
|
throw new Error('Pas de connexion internet. Veuillez vérifier votre connexion réseau.');
|
|
}
|
|
|
|
const [campaignData, participants, propositionsData] = await Promise.all([
|
|
campaignService.getById(campaignId),
|
|
participantService.getByCampaign(campaignId),
|
|
propositionService.getByCampaign(campaignId)
|
|
]);
|
|
const participantData = participants.find(p => p.id === participantId);
|
|
|
|
if (!campaignData) {
|
|
setError('Campagne non trouvée');
|
|
return;
|
|
}
|
|
|
|
if (!participantData) {
|
|
setError('Participant non trouvé');
|
|
return;
|
|
}
|
|
|
|
if (campaignData.status !== 'voting') {
|
|
setError('Cette campagne n\'est pas en phase de vote');
|
|
return;
|
|
}
|
|
|
|
setCampaign(campaignData);
|
|
setParticipant(participantData);
|
|
|
|
// Charger les votes existants
|
|
const votes = await voteService.getByParticipant(campaignId, participantId);
|
|
|
|
// Combiner les propositions avec leurs votes
|
|
let propositionsWithVotes = propositionsData.map(proposition => ({
|
|
...proposition,
|
|
vote: votes.find(vote => vote.proposition_id === proposition.id)
|
|
}));
|
|
|
|
// Vérifier si l'ordre aléatoire est activé
|
|
const randomizePropositions = await settingsService.getBooleanValue('randomize_propositions', true);
|
|
|
|
if (randomizePropositions) {
|
|
// Mélanger les propositions de manière aléatoire
|
|
propositionsWithVotes = propositionsWithVotes.sort(() => Math.random() - 0.5);
|
|
setIsRandomOrder(true);
|
|
}
|
|
|
|
setPropositions(propositionsWithVotes);
|
|
|
|
// Initialiser les votes locaux avec les votes existants
|
|
const initialVotes: Record<string, number> = {};
|
|
votes.forEach(vote => {
|
|
initialVotes[vote.proposition_id] = vote.amount;
|
|
});
|
|
setLocalVotes(initialVotes);
|
|
|
|
} catch (error) {
|
|
console.error('Erreur lors du chargement des données:', error);
|
|
let errorMessage = 'Erreur lors du chargement des données';
|
|
|
|
if (error instanceof Error) {
|
|
errorMessage = error.message;
|
|
} else if (typeof error === 'object' && error !== null) {
|
|
// Essayer d'extraire plus d'informations de l'erreur
|
|
const errorObj = error as any;
|
|
if (errorObj.message) {
|
|
errorMessage = errorObj.message;
|
|
} else if (errorObj.error) {
|
|
errorMessage = errorObj.error;
|
|
} else if (errorObj.details) {
|
|
errorMessage = errorObj.details;
|
|
}
|
|
}
|
|
|
|
setError(errorMessage);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleVoteChange = (propositionId: string, amount: number) => {
|
|
if (amount === 0) {
|
|
// Si on sélectionne "Aucun vote", on supprime le vote local
|
|
const newLocalVotes = { ...localVotes };
|
|
delete newLocalVotes[propositionId];
|
|
setLocalVotes(newLocalVotes);
|
|
} else {
|
|
// Sinon on met à jour le vote local
|
|
setLocalVotes(prev => ({
|
|
...prev,
|
|
[propositionId]: amount
|
|
}));
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (totalVoted !== campaign?.budget_per_user) {
|
|
setError(`Vous devez dépenser exactement ${campaign?.budget_per_user}€`);
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
setError('');
|
|
|
|
try {
|
|
// Préparer les votes à sauvegarder (seulement ceux avec amount > 0)
|
|
const votesToSave = Object.entries(localVotes)
|
|
.filter(([_, amount]) => amount > 0)
|
|
.map(([propositionId, amount]) => ({
|
|
proposition_id: propositionId,
|
|
amount
|
|
}));
|
|
|
|
// Utiliser la méthode atomique pour remplacer tous les votes
|
|
await voteService.replaceVotes(campaignId, participantId, votesToSave);
|
|
|
|
setSuccess(true);
|
|
} catch (error) {
|
|
console.error('Erreur lors de la validation:', error);
|
|
|
|
// Améliorer l'affichage de l'erreur
|
|
let errorMessage = 'Erreur lors de la validation des votes';
|
|
|
|
if (error instanceof Error) {
|
|
errorMessage = error.message;
|
|
} else if (typeof error === 'object' && error !== null) {
|
|
// Essayer d'extraire plus d'informations de l'erreur
|
|
const errorObj = error as any;
|
|
if (errorObj.message) {
|
|
errorMessage = errorObj.message;
|
|
} else if (errorObj.error) {
|
|
errorMessage = errorObj.error;
|
|
} else if (errorObj.details) {
|
|
errorMessage = errorObj.details;
|
|
}
|
|
}
|
|
|
|
setError(errorMessage);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const getSpendingTiers = () => {
|
|
if (!campaign) return [];
|
|
return campaign.spending_tiers.split(',').map(tier => parseInt(tier.trim())).filter(tier => tier > 0);
|
|
};
|
|
|
|
const getVoteStatus = () => {
|
|
if (!campaign) return { status: 'error', message: 'Campagne non trouvée' };
|
|
|
|
const remaining = campaign.budget_per_user - totalVoted;
|
|
|
|
if (remaining === 0) {
|
|
return { status: 'success', message: 'Budget complet ! Vous pouvez valider votre vote.' };
|
|
} else if (remaining > 0) {
|
|
return { status: 'warning', message: `Il vous reste ${remaining}€ à dépenser` };
|
|
} else {
|
|
return { status: 'error', message: `Vous avez dépensé ${Math.abs(remaining)}€ de trop` };
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
|
<p className="mt-4 text-gray-600">Chargement de la page de vote...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error && !campaign) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md mx-auto">
|
|
<svg className="mx-auto h-12 w-12 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
|
</svg>
|
|
<h2 className="mt-4 text-lg font-medium text-gray-900">Erreur</h2>
|
|
<p className="mt-2 text-sm text-gray-600">{error}</p>
|
|
<Link
|
|
href="/"
|
|
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
|
|
>
|
|
Retour à l'accueil
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (success) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md mx-auto">
|
|
<svg className="mx-auto h-12 w-12 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<h2 className="mt-4 text-lg font-medium text-gray-900">Vote enregistré !</h2>
|
|
<p className="mt-2 text-sm text-gray-600">
|
|
Votre vote a été enregistré avec succès. Vous pouvez revenir modifier vos choix tant que la campagne est en cours.
|
|
</p>
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
|
|
>
|
|
Retour à mon vote
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const voteStatus = getVoteStatus();
|
|
const spendingTiers = getSpendingTiers();
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 vote-page">
|
|
{/* Header fixe avec le total et le bouton de validation */}
|
|
<div className="sticky top-0 z-40 bg-white shadow-sm border-b border-gray-200">
|
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-4">
|
|
<div>
|
|
<h1 className="text-lg font-semibold text-gray-900">{campaign?.title}</h1>
|
|
<p className="text-lg font-bold text-indigo-600">
|
|
{participant?.first_name} {participant?.last_name}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-4">
|
|
<div className="text-right">
|
|
<div className={`text-2xl font-bold transition-all duration-300 ${
|
|
isOverBudget
|
|
? 'text-red-600 animate-pulse'
|
|
: totalVoted === campaign?.budget_per_user
|
|
? 'text-green-600 scale-105'
|
|
: totalVoted > 0
|
|
? 'text-indigo-600'
|
|
: 'text-gray-900'
|
|
} ${isOverBudget ? 'animate-bounce' : ''}`}>
|
|
{totalVoted}€ / {campaign?.budget_per_user}€
|
|
</div>
|
|
<div className={`text-sm font-medium transition-colors duration-300 ${
|
|
voteStatus.status === 'success' ? 'text-green-600' :
|
|
voteStatus.status === 'warning' ? 'text-yellow-600' :
|
|
'text-red-600'
|
|
}`}>
|
|
{voteStatus.message}
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={saving || totalVoted !== campaign?.budget_per_user}
|
|
className={`px-6 py-3 text-sm font-medium rounded-lg transition-all duration-200 ${
|
|
totalVoted === campaign?.budget_per_user
|
|
? 'bg-green-600 text-white hover:bg-green-700 shadow-lg'
|
|
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
|
}`}
|
|
>
|
|
{saving ? 'Enregistrement...' : 'Valider mon vote'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 pb-20">
|
|
{/* Informations de la campagne */}
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
|
|
<div className="grid grid-cols-1 md:grid-cols-1 gap-6">
|
|
<div>
|
|
<MarkdownContent
|
|
content={campaign?.description || ''}
|
|
className="mt-1 text-base font-medium text-gray-900"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Message discret sur l'ordre aléatoire */}
|
|
{isRandomOrder && (
|
|
<div className="mb-6 text-center">
|
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs text-gray-400 bg-gray-50 border border-gray-100">
|
|
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
|
</svg>
|
|
Les propositions sont affichées dans un ordre aléatoire pour éviter les biais liés à l'ordre de présentation
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Propositions */}
|
|
{propositions.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucune proposition</h3>
|
|
<p className="mt-1 text-sm text-gray-500">Aucune proposition n'a été soumise pour cette campagne.</p>
|
|
</div>
|
|
) : (
|
|
<div className={`${isCompactView ? 'space-y-3' : 'space-y-6'}`}>
|
|
{propositions.map((proposition, index) => (
|
|
<div
|
|
key={proposition.id}
|
|
data-proposition-index={index + 1}
|
|
className={`rounded-lg shadow-sm border overflow-hidden transition-all duration-200 relative ${
|
|
localVotes[proposition.id] && localVotes[proposition.id] > 0
|
|
? 'border-indigo-400 shadow-lg bg-indigo-100'
|
|
: 'bg-white border-gray-200'
|
|
}`}
|
|
>
|
|
{!isCompactView && (
|
|
<div className="absolute -top-1 left-4 bg-white px-2 text-xs text-gray-500 font-medium z-10 border border-gray-200 rounded-t">
|
|
Proposition
|
|
</div>
|
|
)}
|
|
<div className={`${isCompactView ? 'p-4' : 'p-6'}`}>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<h3 className={`font-medium text-gray-900 ${isCompactView ? 'text-base mb-1' : 'text-lg mb-2'}`}>
|
|
{proposition.title}
|
|
</h3>
|
|
{!isCompactView && (
|
|
<MarkdownContent
|
|
content={proposition.description}
|
|
className="text-sm text-gray-600 mb-4"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className={isCompactView ? 'mt-3' : 'mt-6'}>
|
|
{!isCompactView && (
|
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
|
Pour cette proposition, vous investissez :
|
|
</label>
|
|
)}
|
|
<div className="space-y-4">
|
|
{/* Slider */}
|
|
<div className="relative">
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max={spendingTiers.length}
|
|
step="1"
|
|
value={localVotes[proposition.id] ? (localVotes[proposition.id] === 0 ? 0 : spendingTiers.indexOf(localVotes[proposition.id]) + 1) : 0}
|
|
onChange={(e) => {
|
|
const index = parseInt(e.target.value);
|
|
const amount = index === 0 ? 0 : spendingTiers[index - 1];
|
|
handleVoteChange(proposition.id, amount);
|
|
}}
|
|
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider"
|
|
/>
|
|
|
|
{/* Marqueurs des paliers */}
|
|
<div className="relative mt-3 mb-16" style={{ marginLeft: '12px', marginRight: '24px' }}>
|
|
{/* Marqueur 0€ */}
|
|
<div className="absolute text-center" style={{ left: '0%', transform: 'translateX(-12px)' }}>
|
|
<div className="w-3 h-3 bg-gray-400 rounded-full mx-auto mb-2"></div>
|
|
<span className="text-xs text-gray-600 font-medium whitespace-nowrap">0€</span>
|
|
</div>
|
|
|
|
{/* Marqueurs des paliers */}
|
|
{spendingTiers.map((tier, index) => {
|
|
// Calcul correct de la position pour correspondre au slider
|
|
const position = ((index + 1) / spendingTiers.length) * 100;
|
|
return (
|
|
<div
|
|
key={`tier-${index}-${tier}`}
|
|
className="absolute text-center"
|
|
style={{
|
|
left: `${position}%`,
|
|
transform: 'translateX(-12px)'
|
|
}}
|
|
>
|
|
<div className="w-3 h-3 bg-indigo-500 rounded-full mx-auto mb-2"></div>
|
|
<span className="text-xs text-gray-600 font-medium whitespace-nowrap">{tier}€</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Valeur sélectionnée */}
|
|
{(localVotes[proposition.id] && localVotes[proposition.id] > 0) && !isCompactView && (
|
|
<div className="text-center mt-12">
|
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-indigo-100 text-indigo-800">
|
|
Vote sélectionné : {localVotes[proposition.id]}€
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="mt-6 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer discret */}
|
|
<Footer />
|
|
</div>
|
|
|
|
{/* Barre fixe en bas */}
|
|
<div className="fixed bottom-0 left-0 right-0 z-40 bg-white shadow-lg border-t border-gray-200">
|
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm text-gray-600">
|
|
Proposition {currentVisibleProposition} / {propositions.length}
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2 text-xs text-gray-500">
|
|
<span>Juste les titres</span>
|
|
<button
|
|
onClick={() => setIsCompactView(!isCompactView)}
|
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
|
|
!isCompactView ? 'bg-indigo-600' : 'bg-gray-200'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
|
|
!isCompactView ? 'translate-x-5' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
<span>Avec descriptions</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|